Skip to content

feat(web): Agent Inbox UI — the Claude Design hand-back over the real API (HT-23) - #32

Merged
zaridan merged 18 commits into
mainfrom
feat/ht-23-agent-inbox-ui
Jul 13, 2026
Merged

feat(web): Agent Inbox UI — the Claude Design hand-back over the real API (HT-23)#32
zaridan merged 18 commits into
mainfrom
feat/ht-23-agent-inbox-ui

Conversation

@zaridan

@zaridan zaridan commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

What this is

The first frontend (HT-23): a new web/ npm workspace (Next.js 15, App Router, Node runtime) that turns the Claude Design hand-back into a working Agent Inbox against the real v1.1 API.

Design fidelity

  • web/src/components/ds/** and the token/theme CSS are the hand-back verbatim — every visual decision still flows through the one-file token chain (rebrand = edit tokens/colors.css). A scoped biome override covers the DS files' lint style; a11y improvements are tracked as upstream design-system work, not silent local edits.
  • Screens compose those components per the DS rules: message bands (not bubbles), the accent top bar as the one colored surface, plain-text wordmark, honest failure copy.

Security architecture (the two things that matter)

  1. The Bearer token never leaves the server. src/lib/api.ts imports server-only (client-bundle inclusion = build error); reads are server components, writes are server actions.
  2. bodyHtml renders through exactly one DOMPurify sink (SanitizedHtml, spec §5's stored-XSS contract), with remote images stripped — an inbound <img> is a tracking pixel aimed at the Agent.

The reply contract (spec §4a, client side)

One Idempotency-Key per logical send, minted at draft time and reused verbatim on retry — rotated only on success. 502 send_failed → "Nothing reached the customer. The draft is preserved." 409 retry_in_progress → wait-and-retry with the SAME key. The draft survives every failure mode.

Verified against reality

Ran the HT-24 harness + next dev and drove it in a browser: the inbox rendered seeded v1.1 data (numbers, previews, folders), and a reply typed in the composer went through the server action → real API → real mail engine — a token-bearing Message-ID was minted and logged by the dev sender, the thread persisted sent, and the band re-rendered with "Sent · just now". next build clean; engine suite untouched (397/397); CI gains web typecheck + build steps.

Scope shipped vs next

Shipped: inbox folders (open/closed/spam, keyset load-older), conversation view (inbound/reply/note/failed/customer-viewed bands), reply, close/reopen. Next increments (API already exists for all): notes/tags/assignee/delete editors, keyboard shortcuts, dark-theme toggle — enumerated in web/README.md.

Requesting a real look rather than merge-on-green — this is the first frontend code in the repo and it sets the web architecture (workspace layout, server-only token pattern, DS-verbatim policy).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added the Helpthread Agent Inbox UI: inbox folder navigation, paginated lists, conversation detail with reply composer, and customer panel (including close/reopen/status updates).
    • Added global UI infrastructure: theme switching, keyboard shortcuts, and toasts; plus Settings, and a friendly 404 page.
  • Bug Fixes
    • Sanitizes inbound HTML before rendering.
    • Improves reply sending with preserved drafts and idempotency-safe retries.
  • Documentation
    • Added web app setup/API guidance and UI fidelity requirements.
  • Chores
    • Expanded CI checks and added web project configuration (lint rules, Next.js config, and ignore patterns).

… API (HT-23)

A new npm workspace, web/ (Next.js 15, App Router, Node runtime): the
first frontend, built from the design system handed back by the Claude
Design project — components/ds/** and the token/theme CSS chain ported
VERBATIM (a scoped biome override covers their lint style; real a11y
fixes belong upstream in the design system).

Architecture: the Bearer token never leaves the server. src/lib/api.ts
is typed 1:1 against specs/api/agent-inbox-v1.md v1.1 and imports
server-only; all reads are server components, all writes are server
actions. bodyHtml renders through exactly one DOMPurify sink
(SanitizedHtml, spec §5), remote images stripped. The reply composer
implements §4a's client contract: one Idempotency-Key per logical
send, reused verbatim on retry, rotated only on success; 409/502 keep
the draft with honest copy.

Shipped screens: inbox folders (open/closed/spam, keyset load-older),
conversation view (message bands incl. note/failed/customer-viewed),
reply, close/reopen. Remaining UI surface (notes/tags/assignee/delete
editors, shortcuts, dark toggle) listed in web/README.md — the API for
all of it already exists.

Verified against reality, not just types: dev harness (HT-24) + next
dev, inbox rendered seeded v1.1 data, a reply sent through the REAL
engine (token-bearing Message-ID minted, thread persisted 'sent',
band re-rendered). next build clean; CI gains web typecheck + build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a Next.js Agent Inbox web workspace with typed API access, server actions, reusable design-system components, themed styling, folder navigation, paginated inbox views, conversation details, sanitized HTML rendering, replies, status updates, settings, and shared error handling.

Changes

Web workspace and visual foundation

Layer / File(s) Summary
Workspace, runtime, theme, and documentation setup
.github/workflows/ci.yml, package.json, web/*, biome.json, CLAUDE.md
Adds the web workspace, Next.js configuration, TypeScript settings, CI typecheck/build steps, ignore rules, lint overrides, theme tokens, and setup and fidelity documentation.
Root application shell
web/src/app/layout.tsx, web/src/app/page.tsx, web/src/globals.d.ts
Adds root metadata and layout styling, CSS declarations, and redirects the root route to the open inbox.

API contracts and server access

Layer / File(s) Summary
Typed API and mutation boundary
web/src/lib/api-types.ts, web/src/lib/api.ts, web/src/lib/actions.ts
Defines API data types, authenticated request helpers, typed endpoints, API errors, server actions, cache revalidation, idempotency-key handling, and serializable action results.
Shared formatting helpers
web/src/lib/format.ts
Adds relative timestamp and email-derived display-name formatting functions.

Reusable UI components

Layer / File(s) Summary
Design-system controls and inbox presentation
web/src/components/ds/core/*, web/src/components/ds/inbox/*
Adds typed declarations and React implementations for shared controls, avatars, menus, status indicators, inputs, toasts, folder items, conversation rows, message bands, and toolbar bands.

Inbox and conversation flows

Layer / File(s) Summary
Routes, navigation, and screens
web/src/app/(shell)/*, web/src/components/FolderNav.tsx, web/src/components/InboxScreen.tsx, web/src/components/ConversationScreen.tsx
Adds validated inbox and conversation routes, persistent folder navigation, paginated conversation lists, conversation details, status controls, reply composition, customer details, and router refresh behavior.
Sanitized message rendering
web/src/components/SanitizedHtml.tsx
Sanitizes inbound HTML with DOMPurify before rendering and removes selected tags and attributes.

Application services and settings

Layer / File(s) Summary
Theme, shortcuts, toasts, settings, and error handling
web/src/components/ThemeProvider.tsx, web/src/components/Toaster.tsx, web/src/components/Shortcuts*.tsx, web/src/components/SettingsScreen.tsx, web/src/components/AppError.tsx, web/src/components/AuthFailure.tsx, web/src/app/settings/*, web/src/app/(shell)/error.tsx
Adds persisted theme selection, system-theme synchronization, keyboard shortcut overlay behavior, toast management, deployment and appearance settings, and route error screens.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant NextPage
  participant ServerAction
  participant AgentInboxAPI
  Browser->>NextPage: Open inbox or conversation route
  NextPage->>AgentInboxAPI: Fetch typed conversation data
  AgentInboxAPI-->>NextPage: Return list or detail
  NextPage-->>Browser: Render inbox or conversation screen
  Browser->>ServerAction: Submit reply or status change
  ServerAction->>AgentInboxAPI: Send authenticated mutation
  AgentInboxAPI-->>ServerAction: Return result or error
  ServerAction-->>Browser: Return ActionResult
Loading

Possibly related PRs

  • Helpthread/helpthread#6: Establishes the CI quality workflow extended here with web typecheck and build steps.
  • Helpthread/helpthread#14: Provides the Agent Inbox API foundation corresponding to the typed client and server access added here.
  • Helpthread/helpthread#21: Defines reply idempotency and retry semantics used by the conversation composer and server action.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.28% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: the web Agent Inbox UI built on the real API with the design hand-back.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-23-agent-inbox-ui

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🧹 Nitpick comments (15)
web/src/app/inbox/[folder]/page.tsx (1)

6-6: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive folder allowlist from the shared type instead of duplicating it.

FOLDERS re-declares the same three literals as ConversationFolder in api-types.ts, and a third copy exists in InboxScreen.tsx's FOLDERS array. If a new folder is ever added to the type, this Set is easy to forget, silently 404ing a valid folder.

♻️ Suggested fix
-const FOLDERS: ReadonlySet<string> = new Set(['open', 'closed', 'spam'])
+// api-types.ts
+export const CONVERSATION_FOLDERS = ['open', 'closed', 'spam'] as const
+export type ConversationFolder = (typeof CONVERSATION_FOLDERS)[number]

Then here:

-const FOLDERS: ReadonlySet<string> = new Set(['open', 'closed', 'spam'])
+import { CONVERSATION_FOLDERS } from '../../../lib/api-types'
+const FOLDERS: ReadonlySet<string> = new Set(CONVERSATION_FOLDERS)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/app/inbox/`[folder]/page.tsx at line 6, Update the folder validation
in FOLDERS to derive its allowed values from the shared ConversationFolder type
in api-types.ts rather than duplicating the string literals. Reuse the shared
type or its existing runtime representation, and preserve the current open,
closed, and spam behavior.
web/src/components/ds/core/IconButton.jsx (1)

39-44: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Imperative DOM style mutation on hover bypasses React reconciliation.

Mutating e.currentTarget.style.background directly in onMouseEnter/onMouseLeave works today, but if active/tone change while the pointer is still hovering, the imperative mutation could go stale until the next render forces a full style recalculation. A CSS :hover rule (or hover state via useState) would keep visuals in sync with props consistently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/core/IconButton.jsx` around lines 39 - 44, Replace the
imperative background mutations in IconButton’s onMouseEnter and onMouseLeave
handlers with a React-controlled hover state or CSS :hover styling. Ensure the
rendered background stays synchronized with active and tone/hoverBg prop changes
while the pointer remains over the button, preserving the existing active and
transparent non-hover appearance.
web/src/components/ds/core/DropdownMenu.jsx (1)

13-35: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider Escape-key dismissal for a11y.

The dropdown closes on backdrop click but has no keyboard (Escape) handling, which is a common expectation for menu/dropdown patterns. Given this is a shared design-system primitive used across the inbox UI, adding an Escape handler would improve keyboard accessibility.

♻️ Suggested addition
+import React, { useEffect } from 'react'
+
 export function DropdownMenu({
   open,
   onClose,
   align = 'left',
   top = 36,
   minWidth = 160,
   children,
 }) {
+  useEffect(() => {
+    if (!open) return
+    const onKeyDown = (e) => {
+      if (e.key === 'Escape') onClose()
+    }
+    document.addEventListener('keydown', onKeyDown)
+    return () => document.removeEventListener('keydown', onKeyDown)
+  }, [open, onClose])
+
   if (!open) return null
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/core/DropdownMenu.jsx` around lines 13 - 35, The
DropdownMenu component only dismisses through backdrop clicks and needs keyboard
dismissal. Add an Escape-key listener while the menu is open that invokes
onClose, clean it up when closed or unmounted, and preserve the existing
backdrop and rendering behavior.
web/src/components/ds/core/Skeleton.jsx (1)

4-20: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Duplicate <style> injection and missing a11y hint.

Two things worth addressing:

  • Each Skeleton instance injects its own <style> tag with the same @keyframes ht-pulse rule. If several skeletons render at once (list loading states), this duplicates the same CSS block many times in the DOM.
  • The placeholder has no aria-hidden="true", so it may be announced as meaningless content by screen readers.
♻️ Proposed fix
 export function Skeleton({ width = '100%', height = 12, radius = 4, style }) {
   return (
-    <>
-      <style>{'`@keyframes` ht-pulse{0%,100%{opacity:1}50%{opacity:.45}}'}</style>
-      <div
+    <div
+        aria-hidden="true"
         style={{
           width,
           height,
           borderRadius: radius,
           background: 'var(--ht-surface-2)',
           animation: 'ht-pulse 1.5s ease-in-out infinite',
           ...style,
         }}
-      />
-    </>
+    />
   )
 }

Move the @keyframes ht-pulse definition into the theme CSS (e.g. web/src/theme/helpthread.css) once, instead of per-instance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/core/Skeleton.jsx` around lines 4 - 20, Remove the
per-instance style injection from Skeleton and define the ht-pulse keyframes
once in the shared theme CSS. Add aria-hidden="true" to the Skeleton placeholder
div while preserving its existing styling and animation behavior.
web/src/components/ds/core/TextInput.d.ts (1)

1-9: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace any event types with proper React event types.

@types/react is already a project dependency; use it for stronger typing instead of any, which defeats the purpose of this .d.ts contract.

♻️ Proposed fix
 export interface TextInputProps {
   value?: string
-  onChange?: (e: any) => void
-  onKeyDown?: (e: any) => void
+  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
+  onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
   placeholder?: string
   id?: string
   style?: React.CSSProperties
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/core/TextInput.d.ts` around lines 1 - 9, Update the
TextInputProps onChange and onKeyDown callbacks to use the appropriate React
event types from `@types/react` instead of any, preserving their existing callback
semantics and the rest of the TextInput declaration.
web/src/components/ds/core/MenuItem.jsx (1)

14-41: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Expose selected state to assistive tech.

The visual selected state (line 12, 27) isn't communicated via ARIA. Since this is used as a dropdown menu row, consider adding role="menuitem" and aria-selected={selected} so screen reader users get the same signal as sighted users.

♿ Proposed fix
     <button
       type="button"
+      role="menuitem"
+      aria-selected={selected}
       onClick={onClick}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/core/MenuItem.jsx` around lines 14 - 41, Update the
MenuItem button to expose its selection state to assistive technology by adding
role="menuitem" and binding aria-selected to the existing selected prop. Keep
the current visual styling and click behavior unchanged.
web/src/components/ds/core/TagChip.jsx (1)

21-51: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add aria-label to the icon-only remove button.

title alone isn't a reliable accessible name for icon-only buttons across assistive technologies.

♿ Proposed fix
         <button
           type="button"
           title="Remove tag"
+          aria-label="Remove tag"
           onClick={onRemove}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/core/TagChip.jsx` around lines 21 - 51, Add an explicit
aria-label describing the remove action to the icon-only button in TagChip,
alongside the existing title and onClick behavior. Keep the label specific to
removing the tag.
web/src/components/ds/core/StatusPill.d.ts (1)

1-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a union type for status instead of string.

The comment documents a fixed set of known statuses matching the META map in StatusPill.jsx, but the type allows any string, so typos won't be caught at compile time.

♻️ Proposed fix
 export interface StatusPillProps {
-  /** active | pending | closed | spam | note (unknown = neutral) */
-  status: string
+  /** unknown values fall back to a neutral pill */
+  status: 'active' | 'pending' | 'closed' | 'spam' | 'note' | string
   /** override the derived label */
   label?: string
   style?: React.CSSProperties
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/core/StatusPill.d.ts` around lines 1 - 8, Update
StatusPillProps.status to a string-literal union containing the documented
statuses: active, pending, closed, spam, and note. Preserve the component’s
runtime handling of unknown values as neutral while enabling compile-time
detection of invalid status strings.
web/src/components/ds/inbox/MessageBand.d.ts (1)

7-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

delivery is typed as a plain string despite only two valid values.

The comment documents "sent" | "pending" as the only meaningful values, and the implementation (MessageBand.jsx) branches on delivery === 'pending'. A literal union would catch typos at compile time.

-  /** outbound only: "sent" | "pending" */
-  delivery?: string
+  /** outbound only */
+  delivery?: 'sent' | 'pending'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/inbox/MessageBand.d.ts` around lines 7 - 8, Update the
delivery property type in the MessageBand declaration to the literal union of
"sent" and "pending", preserving its optionality so invalid delivery values are
rejected while omitted values remain valid.
web/src/components/ds/inbox/ConversationRow.d.ts (2)

17-17: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid any for the checkbox change handler.

onCheck?: (e: any) => void discards type safety on the event object. Since the implementation wires this directly to a checkbox onChange, this should be typed against the concrete DOM event.

-  onCheck?: (e: any) => void
+  onCheck?: (e: React.ChangeEvent<HTMLInputElement>) => void
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/inbox/ConversationRow.d.ts` at line 17, Update the
onCheck callback type in ConversationRow’s declaration to use the concrete
checkbox change event type instead of any, matching the DOM event passed by the
checkbox onChange handler.

23-23: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

JSX.Element uses the deprecated global JSX namespace under React 19 types.

React 19's @types/react deprecates the global JSX namespace in favor of React.JSX. It still compiles today (deprecated alias, not removed), but new code should use React.JSX.Element to avoid editor/lint deprecation warnings and future breakage. Same pattern recurs in FolderItem.d.ts, MessageBand.d.ts, and ToolbarBand.d.ts.

-export declare function ConversationRow(props: ConversationRowProps): JSX.Element
+export declare function ConversationRow(props: ConversationRowProps): React.JSX.Element
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/inbox/ConversationRow.d.ts` at line 23, Update the
ConversationRow declaration return type from the deprecated global JSX.Element
to React.JSX.Element, adding or preserving the appropriate React type reference.
Apply the same return-type update to the corresponding declarations in
FolderItem.d.ts, MessageBand.d.ts, and ToolbarBand.d.ts.
web/src/components/ds/inbox/FolderItem.jsx (1)

12-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider exposing active state to assistive tech.

The button visually differentiates the active folder (bold, accent bg) but doesn't expose this via aria-current for screen reader users.

     <button
       type="button"
+      aria-current={active ? 'page' : undefined}
       onClick={onClick}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/inbox/FolderItem.jsx` around lines 12 - 37, Add an
aria-current attribute to the FolderItem button, using the existing active state
so the active folder is exposed to assistive technologies while inactive folders
remain unmarked.
web/src/components/ds/inbox/MessageBand.jsx (1)

97-103: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Decorative icon should be hidden from assistive tech.

The eye SVG conveys no information beyond the adjacent "Customer viewed …" text; it should be marked aria-hidden so screen readers don't announce a redundant/unlabeled graphic.

-            <svg width="12" height="12" viewBox="0 0 24 24">
+            <svg width="12" height="12" viewBox="0 0 24 24" aria-hidden="true">
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/inbox/MessageBand.jsx` around lines 97 - 103, Update
the eye SVG in the viewed-status markup near the “Customer viewed” text to
include aria-hidden="true", keeping the adjacent text and icon styling
unchanged.
biome.json (1)

24-40: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Broad a11y rule suppression for the whole ds/** tree.

useKeyWithClickEvents and noStaticElementInteractions are disabled for every current and future file under web/src/components/ds/**, not just the ported hand-back components. This can silently hide real keyboard/interaction accessibility bugs in new components added later under this path.

Consider narrowing the override (e.g., per-file biome-ignore comments on the offending lines) or adding a tracking comment/issue to revisit and remove this override once the ported components are audited.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@biome.json` around lines 24 - 40, Narrow the biome.json override for
web/src/components/ds/** so useKeyWithClickEvents and
noStaticElementInteractions are not broadly disabled for current and future
components. Prefer targeted per-file or per-line ignores for the ported
components that require them; if the directory-wide override must remain, add a
tracking comment or issue reference documenting its removal after those
components are audited.
web/src/theme/fonts/fonts.css (1)

3-3: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Prefer next/font/google over a manual CSS @import.

A runtime @import from fonts.googleapis.com sends a request (and the user's IP) to Google on every page load, and doesn't get Next.js's automatic self-hosting, preloading, or layout-shift mitigation. Since this is a Next.js 15 app, next/font/google self-hosts the same fonts at build time with no external requests and better CLS behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/theme/fonts/fonts.css` at line 3, Replace the manual Google Fonts
`@import` in fonts.css with Next.js 15's next/font/google integration, configuring
Source Serif 4 and Source Code Pro with the same styles, weights, and display
behavior. Apply the generated font variables or class names through the existing
app layout/theme entry point, and remove the external CSS import.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@package.json`:
- Around line 36-39: Update the root package.json metadata by adding or setting
the top-level private field to true alongside the workspaces configuration,
ensuring the monorepo root is not publishable while preserving the existing
workspace entry.

In `@web/src/components/ConversationScreen.tsx`:
- Around line 63-68: Update changeStatus to handle the failure result from
setStatusAction instead of only refreshing on success. When result.ok is false,
surface the returned error through the same user-facing error mechanism used by
send(), while preserving router.refresh() for successful status changes.
- Line 41: Update the reply validation in the ConversationScreen send flow to
reject whitespace-only text by checking the trimmed value alongside the existing
length limits. Apply the same trimmed-content check to the Send Button’s
disabled condition, replacing the draft.length === 0 guard so blank-looking
replies cannot be sent.

In `@web/src/components/ds/core/Avatar.jsx`:
- Line 38: Update the border value in the Avatar component to use a template
literal instead of string concatenation for the ring width and style, while
preserving the existing size-based widths and conditional border behavior.

In `@web/src/components/ds/core/Toast.jsx`:
- Around line 3-22: Update the Toast component’s docstring to remove the
unsupported auto-dismiss promise, keeping only its presentational behavior; add
role="status" and aria-live="polite" to the rendered toast container so
assistive technologies announce updates.

In `@web/src/components/ds/inbox/ConversationRow.jsx`:
- Around line 23-41: Update the clickable row div in ConversationRow to be
keyboard-accessible by adding an appropriate interactive role, tabIndex, and
onKeyDown handler that invokes the existing onClick for Enter and Space
activation. Preserve the current mouse click behavior and visual styling.

In `@web/src/components/InboxScreen.tsx`:
- Around line 50-56: Update the SVG in FolderIcon to use the explicit static
aria-hidden="true" value instead of JSX boolean shorthand, preserving its
decorative accessibility behavior and satisfying Biome’s noSvgWithoutTitle rule.

In `@web/src/lib/api.ts`:
- Around line 44-51: Update config() so the hardcoded development token is used
only in the local development environment; when HELPTHREAD_API_TOKEN is unset
outside that environment, fail fast instead of returning a fallback token.
Preserve the existing baseUrl normalization and explicitly validate the token
before returning the configuration.
- Around line 53-69: Update the request function’s fetch call to enforce a
finite request timeout, using an AbortController or the project’s existing
timeout mechanism and passing its signal to fetch. Ensure the timeout is cleaned
up and timeout failures flow through the existing ApiError handling path.

In `@web/src/theme/fonts/fonts.css`:
- Line 3: Update the font import declaration in fonts.css to use a quoted string
URL directly after `@import` instead of wrapping the URL in url(). Preserve the
existing Google Fonts URL and all requested font parameters unchanged.

In `@web/src/theme/helpthread.css`:
- Around line 9-11: Update the three local `@import` statements in helpthread.css
to use string notation instead of url() notation, matching the style used in
fonts.css and satisfying the import-notation rule.

In `@web/src/theme/tokens/typography.css`:
- Around line 5-7: Update the --ht-sans, --ht-display, and --ht-serif
font-family declarations to quote the unquoted font names Roboto, Arial, and
Georgia, preserving the existing font order and fallbacks.

---

Nitpick comments:
In `@biome.json`:
- Around line 24-40: Narrow the biome.json override for web/src/components/ds/**
so useKeyWithClickEvents and noStaticElementInteractions are not broadly
disabled for current and future components. Prefer targeted per-file or per-line
ignores for the ported components that require them; if the directory-wide
override must remain, add a tracking comment or issue reference documenting its
removal after those components are audited.

In `@web/src/app/inbox/`[folder]/page.tsx:
- Line 6: Update the folder validation in FOLDERS to derive its allowed values
from the shared ConversationFolder type in api-types.ts rather than duplicating
the string literals. Reuse the shared type or its existing runtime
representation, and preserve the current open, closed, and spam behavior.

In `@web/src/components/ds/core/DropdownMenu.jsx`:
- Around line 13-35: The DropdownMenu component only dismisses through backdrop
clicks and needs keyboard dismissal. Add an Escape-key listener while the menu
is open that invokes onClose, clean it up when closed or unmounted, and preserve
the existing backdrop and rendering behavior.

In `@web/src/components/ds/core/IconButton.jsx`:
- Around line 39-44: Replace the imperative background mutations in IconButton’s
onMouseEnter and onMouseLeave handlers with a React-controlled hover state or
CSS :hover styling. Ensure the rendered background stays synchronized with
active and tone/hoverBg prop changes while the pointer remains over the button,
preserving the existing active and transparent non-hover appearance.

In `@web/src/components/ds/core/MenuItem.jsx`:
- Around line 14-41: Update the MenuItem button to expose its selection state to
assistive technology by adding role="menuitem" and binding aria-selected to the
existing selected prop. Keep the current visual styling and click behavior
unchanged.

In `@web/src/components/ds/core/Skeleton.jsx`:
- Around line 4-20: Remove the per-instance style injection from Skeleton and
define the ht-pulse keyframes once in the shared theme CSS. Add
aria-hidden="true" to the Skeleton placeholder div while preserving its existing
styling and animation behavior.

In `@web/src/components/ds/core/StatusPill.d.ts`:
- Around line 1-8: Update StatusPillProps.status to a string-literal union
containing the documented statuses: active, pending, closed, spam, and note.
Preserve the component’s runtime handling of unknown values as neutral while
enabling compile-time detection of invalid status strings.

In `@web/src/components/ds/core/TagChip.jsx`:
- Around line 21-51: Add an explicit aria-label describing the remove action to
the icon-only button in TagChip, alongside the existing title and onClick
behavior. Keep the label specific to removing the tag.

In `@web/src/components/ds/core/TextInput.d.ts`:
- Around line 1-9: Update the TextInputProps onChange and onKeyDown callbacks to
use the appropriate React event types from `@types/react` instead of any,
preserving their existing callback semantics and the rest of the TextInput
declaration.

In `@web/src/components/ds/inbox/ConversationRow.d.ts`:
- Line 17: Update the onCheck callback type in ConversationRow’s declaration to
use the concrete checkbox change event type instead of any, matching the DOM
event passed by the checkbox onChange handler.
- Line 23: Update the ConversationRow declaration return type from the
deprecated global JSX.Element to React.JSX.Element, adding or preserving the
appropriate React type reference. Apply the same return-type update to the
corresponding declarations in FolderItem.d.ts, MessageBand.d.ts, and
ToolbarBand.d.ts.

In `@web/src/components/ds/inbox/FolderItem.jsx`:
- Around line 12-37: Add an aria-current attribute to the FolderItem button,
using the existing active state so the active folder is exposed to assistive
technologies while inactive folders remain unmarked.

In `@web/src/components/ds/inbox/MessageBand.d.ts`:
- Around line 7-8: Update the delivery property type in the MessageBand
declaration to the literal union of "sent" and "pending", preserving its
optionality so invalid delivery values are rejected while omitted values remain
valid.

In `@web/src/components/ds/inbox/MessageBand.jsx`:
- Around line 97-103: Update the eye SVG in the viewed-status markup near the
“Customer viewed” text to include aria-hidden="true", keeping the adjacent text
and icon styling unchanged.

In `@web/src/theme/fonts/fonts.css`:
- Line 3: Replace the manual Google Fonts `@import` in fonts.css with Next.js 15's
next/font/google integration, configuring Source Serif 4 and Source Code Pro
with the same styles, weights, and display behavior. Apply the generated font
variables or class names through the existing app layout/theme entry point, and
remove the external CSS import.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8b0a2f83-75c5-4832-9e07-975bb32cfe62

📥 Commits

Reviewing files that changed from the base of the PR and between b70fc79 and bb45ec2.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (58)
  • .github/workflows/ci.yml
  • biome.json
  • package.json
  • web/.gitignore
  • web/README.md
  • web/next-env.d.ts
  • web/next.config.mjs
  • web/package.json
  • web/src/app/conversations/[id]/page.tsx
  • web/src/app/inbox/[folder]/page.tsx
  • web/src/app/layout.tsx
  • web/src/app/page.tsx
  • web/src/components/ConversationScreen.tsx
  • web/src/components/InboxScreen.tsx
  • web/src/components/SanitizedHtml.tsx
  • web/src/components/ds/core/Avatar.d.ts
  • web/src/components/ds/core/Avatar.jsx
  • web/src/components/ds/core/Button.d.ts
  • web/src/components/ds/core/Button.jsx
  • web/src/components/ds/core/DropdownMenu.d.ts
  • web/src/components/ds/core/DropdownMenu.jsx
  • web/src/components/ds/core/EmptyState.d.ts
  • web/src/components/ds/core/EmptyState.jsx
  • web/src/components/ds/core/IconButton.d.ts
  • web/src/components/ds/core/IconButton.jsx
  • web/src/components/ds/core/Kbd.d.ts
  • web/src/components/ds/core/Kbd.jsx
  • web/src/components/ds/core/MenuItem.d.ts
  • web/src/components/ds/core/MenuItem.jsx
  • web/src/components/ds/core/Skeleton.d.ts
  • web/src/components/ds/core/Skeleton.jsx
  • web/src/components/ds/core/StatusPill.d.ts
  • web/src/components/ds/core/StatusPill.jsx
  • web/src/components/ds/core/TagChip.d.ts
  • web/src/components/ds/core/TagChip.jsx
  • web/src/components/ds/core/TextInput.d.ts
  • web/src/components/ds/core/TextInput.jsx
  • web/src/components/ds/core/Toast.d.ts
  • web/src/components/ds/core/Toast.jsx
  • web/src/components/ds/inbox/ConversationRow.d.ts
  • web/src/components/ds/inbox/ConversationRow.jsx
  • web/src/components/ds/inbox/FolderItem.d.ts
  • web/src/components/ds/inbox/FolderItem.jsx
  • web/src/components/ds/inbox/MessageBand.d.ts
  • web/src/components/ds/inbox/MessageBand.jsx
  • web/src/components/ds/inbox/ToolbarBand.d.ts
  • web/src/components/ds/inbox/ToolbarBand.jsx
  • web/src/globals.d.ts
  • web/src/lib/actions.ts
  • web/src/lib/api-types.ts
  • web/src/lib/api.ts
  • web/src/lib/format.ts
  • web/src/theme/fonts/fonts.css
  • web/src/theme/helpthread.css
  • web/src/theme/tokens/colors.css
  • web/src/theme/tokens/shape.css
  • web/src/theme/tokens/typography.css
  • web/tsconfig.json

Comment thread package.json
Comment on lines +36 to +39
},
"workspaces": [
"web"
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check for "private" field and package manager hints.
rg -n '"private"|"packageManager"|"name"|"version"' package.json
fd -H 'pnpm-lock.yaml|yarn.lock|package-lock.json' .

Repository: Helpthread/helpthread

Length of output: 251


Mark the workspace root private

package.json is still publishable (private: false). With workspaces, set "private": true so the monorepo root can’t be published by mistake.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` around lines 36 - 39, Update the root package.json metadata by
adding or setting the top-level private field to true alongside the workspaces
configuration, ensuring the monorepo root is not publishable while preserving
the existing workspace entry.

Comment thread web/src/components/ConversationScreen.tsx Outdated
Comment thread web/src/components/ConversationScreen.tsx Outdated
fontSize: Math.round(size * 0.34),
background: bg,
color: fg,
border: ring ? (size >= 48 ? '3px' : '2px') + ' solid var(--ht-surface)' : 'none',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix Biome useTemplate lint failure.

CI flags this line for string concatenation instead of a template literal.

🔧 Proposed fix
-        border: ring ? (size >= 48 ? '3px' : '2px') + ' solid var(--ht-surface)' : 'none',
+        border: ring ? `${size >= 48 ? '3px' : '2px'} solid var(--ht-surface)` : 'none',
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
border: ring ? (size >= 48 ? '3px' : '2px') + ' solid var(--ht-surface)' : 'none',
border: ring ? `${size >= 48 ? '3px' : '2px'} solid var(--ht-surface)` : 'none',
🧰 Tools
🪛 GitHub Actions: CI / 0_Quality (typecheck, lint, test, coverage).txt

[error] 38-38: Biome lint/style/useTemplate: FIXABLE. Template literals are preferred over string concatenation. Unsafe fix: Use a template literal.

🪛 GitHub Actions: CI / Quality (typecheck, lint, test, coverage)

[error] 38-38: Biome lint/style/useTemplate FIXABLE: i Template literals are preferred over string concatenation. Unsafe fix: Use a template literal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/core/Avatar.jsx` at line 38, Update the border value in
the Avatar component to use a template literal instead of string concatenation
for the ring width and style, while preserving the existing size-based widths
and conditional border behavior.

Source: Pipeline failures

Comment on lines +3 to +22
/** Inverse-fill toast, bottom-right. One msg line + optional detail. Auto-dismiss ~4.2s in-app. */
export function Toast({ message, detail, fixed = false, style }) {
return (
<div
style={{
...(fixed ? { position: 'fixed', right: 20, bottom: 20, zIndex: 70 } : {}),
maxWidth: 340,
background: 'var(--ht-inverse-bg)',
color: 'var(--ht-inverse-fg)',
borderRadius: 'var(--ht-radius-md)',
padding: '12px 16px',
boxShadow: 'var(--ht-shadow-md)',
...style,
}}
>
<div style={{ fontSize: 13.5, fontWeight: 600 }}>{message}</div>
{detail && <div style={{ marginTop: 3, fontSize: 12.5, opacity: 0.75 }}>{detail}</div>}
</div>
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Toast file ==\n'
cat -n web/src/components/ds/core/Toast.jsx

printf '\n== Toast usages ==\n'
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' "\bToast\b" web/src | sed -n '1,200p'

printf '\n== Toast-related files ==\n'
fd -i "toast" web/src | sed -n '1,200p'

Repository: Helpthread/helpthread

Length of output: 1412


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Toast.d.ts ==\n'
cat -n web/src/components/ds/core/Toast.d.ts

printf '\n== Live region / toast patterns ==\n'
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' \
  'aria-live|role="status"|role="alert"|aria-live=' web/src | sed -n '1,240p'

printf '\n== Notification-like components ==\n'
fd -i 'notification|alert|banner|toast' web/src | sed -n '1,240p'

Repository: Helpthread/helpthread

Length of output: 484


Clarify the toast contract and add live-region semantics

  • Toast is presentational and has no auto-dismiss logic, so the docstring should not promise Auto-dismiss ~4.2s in-app unless a wrapper handles that behavior.
  • Add role="status" and aria-live="polite" so screen readers announce the toast.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/core/Toast.jsx` around lines 3 - 22, Update the Toast
component’s docstring to remove the unsupported auto-dismiss promise, keeping
only its presentational behavior; add role="status" and aria-live="polite" to
the rendered toast container so assistive technologies announce updates.

Comment thread web/src/lib/api.ts
Comment thread web/src/lib/api.ts
@@ -0,0 +1,3 @@
/* Webfonts: Source Serif 4 (wordmark) + Source Code Pro (mono), both OFL.
The UI sans is the native system stack — intentionally not a webfont. */
@import url("https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,400..700;1,8..60,400..600&family=Source+Code+Pro:wght@400;500&display=swap");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use string notation for @import, not url().

Stylelint flags this: expects @import "https://..." instead of @import url("https://...").

🔧 Proposed fix
-@import url("https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,400..700;1,8..60,400..600&family=Source+Code+Pro:wght@400;500&display=swap");
+@import "https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,400..700;1,8..60,400..600&family=Source+Code+Pro:wght@400;500&display=swap";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@import url("https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,400..700;1,8..60,400..600&family=Source+Code+Pro:wght@400;500&display=swap");
`@import` "https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,400..700;1,8..60,400..600&family=Source+Code+Pro:wght@400;500&display=swap";
🧰 Tools
🪛 Stylelint (17.14.0)

[error] 3-3: Expected "url("https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,400..700;1,8..60,400..600&family=Source+Code+Pro:wght@400;500&display=swap")" to be ""https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,400..700;1,8..60,400..600&family=Source+Code+Pro:wght@400;500&display=swap"" (import-notation)

(import-notation)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/theme/fonts/fonts.css` at line 3, Update the font import declaration
in fonts.css to use a quoted string URL directly after `@import` instead of
wrapping the URL in url(). Preserve the existing Google Fonts URL and all
requested font parameters unchanged.

Source: Linters/SAST tools

Comment on lines +9 to +11
@import url("./tokens/colors.css");
@import url("./tokens/typography.css");
@import url("./tokens/shape.css");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use string notation for local @imports.

Same stylelint import-notation violation as in fonts.css, applied to all three local token imports.

🔧 Proposed fix
-@import url("./tokens/colors.css");
-@import url("./tokens/typography.css");
-@import url("./tokens/shape.css");
+@import "./tokens/colors.css";
+@import "./tokens/typography.css";
+@import "./tokens/shape.css";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@import url("./tokens/colors.css");
@import url("./tokens/typography.css");
@import url("./tokens/shape.css");
`@import` "./tokens/colors.css";
`@import` "./tokens/typography.css";
`@import` "./tokens/shape.css";
🧰 Tools
🪛 Stylelint (17.14.0)

[error] 9-9: Expected "url("./tokens/colors.css")" to be ""./tokens/colors.css"" (import-notation)

(import-notation)


[error] 10-10: Expected "url("./tokens/typography.css")" to be ""./tokens/typography.css"" (import-notation)

(import-notation)


[error] 11-11: Expected "url("./tokens/shape.css")" to be ""./tokens/shape.css"" (import-notation)

(import-notation)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/theme/helpthread.css` around lines 9 - 11, Update the three local
`@import` statements in helpthread.css to use string notation instead of url()
notation, matching the style used in fonts.css and satisfying the
import-notation rule.

Source: Linters/SAST tools

Comment on lines +5 to +7
--ht-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--ht-display: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--ht-serif: "Source Serif 4", Georgia, serif;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix stylelint value-keyword-case failures on unquoted font names.

Stylelint flags Roboto, Arial, and Georgia for casing since they're unquoted keywords. Quoting them (consistent with the other multi-word font names already quoted here) sidesteps the case rule and matches convention.

🎨 Proposed fix
-  --ht-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-  --ht-display: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-  --ht-serif: "Source Serif 4", Georgia, serif;
+  --ht-sans: system-ui, -apple-system, "Segoe UI", "Roboto", "Helvetica Neue", "Arial", sans-serif;
+  --ht-display: system-ui, -apple-system, "Segoe UI", "Roboto", "Helvetica Neue", "Arial", sans-serif;
+  --ht-serif: "Source Serif 4", "Georgia", serif;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
--ht-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--ht-display: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--ht-serif: "Source Serif 4", Georgia, serif;
--ht-sans: system-ui, -apple-system, "Segoe UI", "Roboto", "Helvetica Neue", "Arial", sans-serif;
--ht-display: system-ui, -apple-system, "Segoe UI", "Roboto", "Helvetica Neue", "Arial", sans-serif;
--ht-serif: "Source Serif 4", "Georgia", serif;
🧰 Tools
🪛 Stylelint (17.14.0)

[error] 5-5: Expected "Roboto" to be "roboto" (value-keyword-case)

(value-keyword-case)


[error] 5-5: Expected "Arial" to be "arial" (value-keyword-case)

(value-keyword-case)


[error] 6-6: Expected "Roboto" to be "roboto" (value-keyword-case)

(value-keyword-case)


[error] 6-6: Expected "Arial" to be "arial" (value-keyword-case)

(value-keyword-case)


[error] 7-7: Expected "Georgia" to be "georgia" (value-keyword-case)

(value-keyword-case)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/theme/tokens/typography.css` around lines 5 - 7, Update the
--ht-sans, --ht-display, and --ht-serif font-family declarations to quote the
unquoted font names Roboto, Arial, and Georgia, preserving the existing font
order and fallbacks.

Source: Linters/SAST tools

zaridan and others added 5 commits July 12, 2026 11:14
…ation context panel

The folder sidebar moves out of InboxScreen into a (shell) route-group
layout shared by /inbox and /conversations, so it persists like the
design's anatomy intends (active folder derived from the URL; no counts
yet — the list API has no totals, keyset pagination, and a fake number
would be worse than none). The conversation view gains the right-hand
Customer context panel: avatar, address, status pill, started/last
activity, message count, tags — capped by the panel-tone toolbar band
per the DS layer model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…vatar.jsx)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…source of truth (TJ, 2026-07-12)

The dogfood site must match the designed prototype exactly — the whole
surface, not a subset. Encoded in CLAUDE.md (binding for every session)
and web/README.md; the live gap list is the fidelity checklist on HT-23.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…verlay, settings/401/404 screens (HT-23 checklist §1/§5)

Sonnet-built, orchestrator-reviewed. Toast system on the DS Toast
(4.2s, bottom-right); light/dark/system theme with pre-hydration
attribute script (no wrong-theme flash), localStorage persistence, and
live prefers-color-scheme tracking; /settings (Deployment read-only,
Appearance control, branding-in-one-file card); designed 401 screen
('…this is configuration, not a login.') wired through error
boundaries via an unauthorized: message prefix; designed 404; the ?
shortcuts overlay with the full key table (navigation keys themselves
are the next increment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread web/src/lib/theme.ts
Comment on lines +26 to +28
export const THEME_INIT_SCRIPT = `(function(){try{var k=${JSON.stringify(
THEME_STORAGE_KEY,
)};var t=localStorage.getItem(k);if(t!=='light'&&t!=='dark'&&t!=='system')t='system';var dark=t==='dark'||(t==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);if(dark)document.documentElement.setAttribute('data-theme','dark');}catch(e){}})();`

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/src/app/not-found.tsx`:
- Around line 22-28: Update the root-level not-found component’s EmptyState copy
to use route-neutral wording that applies to any unmatched page or folder,
rather than referring specifically to a missing conversation; keep the existing
layout and inbox link unchanged.

In `@web/src/components/AppError.tsx`:
- Around line 3-9: Move the 401 detection out of the client-side AppError
boundary and into the API request/error handling path before the error is
thrown, using the response status rather than error.message. Remove the
unauthorized-prefix check and AuthFailure branch from AppError, while preserving
the generic fallback for other errors and ensuring 401 responses still reach the
AuthFailure screen.

In `@web/src/components/ShortcutsOverlay.tsx`:
- Around line 35-52: Update ShortcutsOverlay to give the dialog an initial
keyboard focus when it opens, prioritizing its close control. Add the necessary
ref and mount-focus behavior to the close control without changing the
documented Esc and header-chip dismissal behavior; a full Tab trap and focus
restoration are not required.

In `@web/src/components/ThemeProvider.tsx`:
- Around line 32-50: Gate the theme-apply effect in ThemeProvider until the
localStorage hydration effect has completed, preventing its initial 'system'
pass from calling applyTheme. Preserve applying the persisted or selected theme
and registering the system preference listener after the stored choice is
loaded.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f8693d7-c335-4664-b902-b4717728991b

📥 Commits

Reviewing files that changed from the base of the PR and between 6a5abcd and 6ae80f2.

📒 Files selected for processing (14)
  • web/src/app/(shell)/error.tsx
  • web/src/app/layout.tsx
  • web/src/app/not-found.tsx
  • web/src/app/settings/error.tsx
  • web/src/app/settings/page.tsx
  • web/src/components/AppError.tsx
  • web/src/components/AuthFailure.tsx
  • web/src/components/SettingsScreen.tsx
  • web/src/components/ShortcutsOverlay.tsx
  • web/src/components/ShortcutsProvider.tsx
  • web/src/components/ThemeProvider.tsx
  • web/src/components/Toaster.tsx
  • web/src/lib/api.ts
  • web/src/lib/theme.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • web/src/lib/api.ts

Comment thread web/src/app/not-found.tsx
Comment on lines +22 to +28
<EmptyState
title="Nothing at this address."
body="This conversation doesn't exist — it may have been removed, or the link is wrong."
/>
<Link href="/inbox/open" style={{ fontSize: 13, color: 'var(--ht-accent)' }}>
Back to inbox
</Link>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use route-neutral copy for the global 404.

This root-level component handles arbitrary unmatched routes and unknown folders, but the message says every failure is a missing conversation. Use page-neutral copy, or move conversation-specific copy into a conversation-segment not-found.tsx.

Proposed fix
-          body="This conversation doesn't exist — it may have been removed, or the link is wrong."
+          body="This page doesn't exist, or the link is wrong."
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<EmptyState
title="Nothing at this address."
body="This conversation doesn't exist — it may have been removed, or the link is wrong."
/>
<Link href="/inbox/open" style={{ fontSize: 13, color: 'var(--ht-accent)' }}>
Back to inbox
</Link>
<EmptyState
title="Nothing at this address."
body="This page doesn't exist, or the link is wrong."
/>
<Link href="/inbox/open" style={{ fontSize: 13, color: 'var(--ht-accent)' }}>
Back to inbox
</Link>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/app/not-found.tsx` around lines 22 - 28, Update the root-level
not-found component’s EmptyState copy to use route-neutral wording that applies
to any unmatched page or folder, rather than referring specifically to a missing
conversation; keep the existing layout and inbox link unchanged.

Comment thread web/src/components/AppError.tsx
Comment thread web/src/components/ShortcutsOverlay.tsx
Comment on lines +32 to +50
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>('system')

// Adopt the persisted choice once mounted (see the hydration note above).
useEffect(() => {
const stored = window.localStorage.getItem(THEME_STORAGE_KEY)
if (stored === 'light' || stored === 'dark' || stored === 'system') {
setThemeState(stored)
}
}, [])

useEffect(() => {
applyTheme(theme)
if (theme !== 'system') return
const mql = window.matchMedia('(prefers-color-scheme: dark)')
const listener = () => applyTheme('system')
mql.addEventListener('change', listener)
return () => mql.removeEventListener('change', listener)
}, [theme])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the target file and inspect the relevant section with line numbers.
git ls-files 'web/src/components/ThemeProvider.tsx'
echo "-----"
cat -n web/src/components/ThemeProvider.tsx | sed -n '1,220p'

echo "-----"
# Find any hydration script / theme bootstrap references used by this component.
rg -n "THEME_INIT_SCRIPT|THEME_STORAGE_KEY|applyTheme|prefers-color-scheme|ThemeProvider" web/src/components web/src -g '!**/*.map'

Repository: Helpthread/helpthread

Length of output: 6721


Gate the theme-apply effect until the stored choice is loaded.

On mount, the first applyTheme(theme) pass still runs with the initial 'system' state. If the persisted theme is 'light'/'dark' and differs from the OS preference, that pass can briefly override the pre-hydration theme set by THEME_INIT_SCRIPT before the state update lands.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ThemeProvider.tsx` around lines 32 - 50, Gate the
theme-apply effect in ThemeProvider until the localStorage hydration effect has
completed, preventing its initial 'system' pass from calling applyTheme.
Preserve applying the persisted or selected theme and registering the system
preference listener after the stored choice is loaded.

zaridan and others added 12 commits July 12, 2026 12:35
…ist §1/§2)

Sonnet-built, orchestrator-reviewed. Top bar: wordmark → /dashboard,
Mailbox tab, Manage dropdown, notifications bell (6 most recent open,
'Nothing new right now.'), Agent avatar menu with the designed log-out
stub copy. /dashboard mailbox card. Folder rail: Unassigned/Mine/
Starred/Drafts/Assigned/Closed/Spam with real counts ('50+' on
next-cursor, hidden at 0), mailbox header block, Settings/New-message
split pill. Assignment folders derive client-side from the open list;
Starred/Drafts are localStorage-backed (drafts write path arrives with
the composer increment). Row star is now a real toggle with same-tab
event sync. /inbox/open redirects to /inbox/unassigned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…place paging, skeletons, hints (HT-23 checklist §3)

Sonnet-built, orchestrator-reviewed. Column header (select-all,
CUSTOMER/CONVERSATION/NUMBER, presentation-layer 'Waiting since ↓/↑'
sort) that swaps to the bulk bar on selection: Set-status dropdown (one
PATCH per conversation, honest singular/plural toasts), two-step
Delete on the DS armed pattern (3.5s auto-disarm), Clear. In-place
'Load older conversations' via a loadOlder server action (closed/spam).
Route-level skeleton rows, 'The inbox couldn't load.' error copy, and
the j/k/enter/? footer hints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…read, message menus, original-source modal (HT-23 checklist §4)

Sonnet-built, orchestrator-reviewed. Toolbar per the prototype: reply/
note/delete(two-step)/tags-editor(optimistic replace-set with
rollback)/star/more(Follow, Forward, Merge stubs, Print), assignee and
status dropdowns, '{i} of {n}' prev/next over the open folder. Threads
present newest-first (sameSpeakerAsPrev computed chronologically before
the reverse). Per-message menu (Copy text, Show original) and the
never-rendered source modal with its verbatim caption. 'HTML email ·
sanitized · external images blocked' caption on sanitized bodies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…4/§5)

Sonnet-built, orchestrator-reviewed. The composer is summoned (toolbar,
r/n, or auto when a draft exists), with Reply/Note pill tabs, the
verbatim note and closed-reopens banners, B/I/list/link formatting
sent as HTML alongside plain text, counter, validation copy, the
send-failure banner with same-key Retry (fresh key only after a 400),
success toasts incl. 'Reply sent — conversation reopened', and
debounced localStorage drafts that make the Drafts folder live.
Keyboard: inbox j/k/arrows/Enter/x with a focused-row cursor;
conversation j/k prev-next, r/n, Cmd/Ctrl+Enter send, cascading
Escape (modal -> menus -> composer -> inbox); coordinated with the ?
overlay. Verified end-to-end incl. a bold-formatted reply through the
real engine (HTML alongside text in the dev-sender log).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er, Settings/New-message as an icon split-pill under the folders (HT-23)

Verified against the rendered prototype (Helpthread App.dc.html):
- Manage moves from the right cluster into the top-left after Mailbox
  (wordmark · divider · Mailbox · Manage); right cluster keeps only the
  bell + avatar.
- The Settings/New-message control moves from a bottom-pinned text pill
  to a rounded icon split-pill (tune glyph + envelope) sitting directly
  under the folder list, not at the column bottom.

Corrects three deviations the earlier inventory recorded wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The keyboard-nav cursor started at row 0, so the first row rendered
highlighted on load — the design's table has no selected row by
default. Start the cursor at -1 (nothing focused); the first j/k/arrow
lands on row 0, so keyboard nav is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Manage in the top bar now shows a down-chevron (matches the design's 'Manage ⌄').
- The sidebar Settings icon changes from the tune/sliders glyph to a
  cog per TJ's direction (note: the prototype shows sliders there — this
  is a deliberate, TJ-requested departure).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…text panel (HT-23)

Verified against the rendered prototype's conversation view:
- Toolbar: drop the back button; delete trash is red by default (still
  two-step armed); assignee/status become icon pill-buttons (person +
  Anyone; flag + accent-tinted status).
- Subject moves to its own row below the toolbar; the #number is dropped
  from the conversation header.
- Context panel: gear-icon header (no CUSTOMER label), large ring avatar
  overlapping the header edge, name + email; the Status/Started/Last-
  activity/Messages metadata block and the tag chips are removed (not in
  the design); adds the collapsible 'Previous conversations' list for the
  same customer (open+closed, filtered client-derivable, hidden when none).
- Message-band timestamps use absolute clock time per the design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Design side-by-side over the rest of the app (Sonnet audit against the
rendered prototype, orchestrator-reviewed):
- Composer: format buttons are plain icons (no grey group); the
  'Formatting is sent as HTML…' caption moves onto the format-toolbar
  row before the ×. (Reply/Note, counter, send hint already matched.)
- Shortcuts overlay: restore the 9th row ('Next / previous conversation
  (while reading)') and the design's verbatim row labels.
- 401 AuthFailure: correct the body copy to the prototype's wording
  (wordmark, 'Every request is signed with this deployment's service
  token…', the update-token caption, outline Reload button).

And a genuine bug that only a live 401 revealed: the AuthFailure screen
NEVER rendered on a real 401. The root layout fetched the notifications
bell data unguarded, so a 401 there threw ABOVE every route error
boundary and hard-crashed the app. Fixes: make the layout's bell fetch
resilient (swallow → empty bell) so the 401 resurfaces from the
page-level fetch where the boundaries catch it and route to AuthFailure;
add a top-level app/error.tsx so /dashboard (outside the (shell) group)
is covered too. Verified live: a bad token now shows the full-screen
AuthFailure, not a crash.

Dashboard, Settings, 404, and the notifications/avatar/more/tags menus
were audited and already matched — left untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…me (CI lint)

app/error.tsx's default export was named Error, which biome's
noShadowRestrictedNames rejects; renamed to RootError to match the
existing ShellError/SettingsError boundaries. (This slipped local
verification because 'npm run lint | tail' masks the command's exit
code — checked by exit code now.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, empty-draft trim, status-failure toast, shortcuts focus (HT-23)

- api.ts: require HELPTHREAD_API_URL/TOKEN in a running production server
  (skip during next build); bound every upstream fetch with a 15s timeout
  so a hung API fails fast instead of hanging the render.
- ConversationScreen: reject whitespace-only reply/note drafts (.trim());
  changeStatus now surfaces a failure toast like tags/assignee (was
  silently swallowed).
- ShortcutsOverlay: focus the close button on open, return focus on
  close, and trap Tab — baseline modal a11y.

Remaining CodeRabbit notes are against verbatim design-system files
(Toast/Avatar/ConversationRow a11y, theme CSS @import syntax) — those
belong upstream in the design project, not as local edits; and the
404's copy is the design's verbatim wording (kept per the fidelity
mandate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CodeRabbit flagged AppError as Critical, and it was right. The 401 →
AuthFailure path keyed off the `unauthorized:` prefix on error.message.
That works in `next dev` (which forwards messages to the client error
boundary) but NOT in a production build: Next.js strips a Server
Component error's message and forwards only error.digest. Since the
inbox reads are server components, a real 401 in prod fell through to
the generic "The inbox couldn't load." fallback — the designed
AuthFailure screen never rendered. The earlier live check passed only
because it ran against `next dev`.

Fix: set a sentinel error.digest ('unauthorized', in the new
lib/auth-error.ts so the server-only api.ts and the client AppError
can both import it) on the 401 throw, and match on digest in AppError
(message prefix kept as a dev-only fallback). digest is the same
channel notFound()/redirect() use, so it survives prod sanitization.

Verified against a PRODUCTION build (next build && next start), not
dev: with a wrong token the AuthFailure screen renders (RSC payload
carries digest":"unauthorized"); with the correct token the inbox
loads normally (HTTP 200, folders render). typecheck + biome clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants